//
//  RetryStrategyTests.swift
//  Kingfisher
//
//  Created by onevcat on 2020/05/06.
//
//  Copyright (c) 2020 Wei Wang <onevcat@gmail.com>
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.

import XCTest
@testable import Kingfisher

class RetryStrategyTests: XCTestCase {

    var manager: KingfisherManager!

    override class func setUp() {
        super.setUp()
        LSNocilla.sharedInstance().start()
    }

    override class func tearDown() {
        LSNocilla.sharedInstance().stop()
        super.tearDown()
    }

    override func setUpWithError() throws {
        try super.setUpWithError()
        let uuid = UUID()
        let downloader = ImageDownloader(name: "test.manager.\(uuid.uuidString)")
        let cache = ImageCache(name: "test.cache.\(uuid.uuidString)")

        manager = KingfisherManager(downloader: downloader, cache: cache)
        manager.defaultOptions = [.waitForCache]
    }

    override func tearDownWithError() throws {
        LSNocilla.sharedInstance().clearStubs()
        clearCaches([manager.cache])
        cleanDefaultCache()
        manager = nil
        try super.tearDownWithError()
    }

    func testCanCreateRetryStrategy() {
        let strategy = DelayRetryStrategy(maxRetryCount: 10, retryInterval: .seconds(5))
        XCTAssertEqual(strategy.maxRetryCount, 10)
        XCTAssertEqual(strategy.retryInterval.timeInterval(for: 0), 5)
    }


    func testDelayRetryIntervalCalculating() {
        let secondInternal = DelayRetryStrategy.Interval.seconds(10)
        XCTAssertEqual(secondInternal.timeInterval(for: 0), 10)

        let accumulatedInternal = DelayRetryStrategy.Interval.accumulated(3)
        XCTAssertEqual(accumulatedInternal.timeInterval(for: 0), 3)
        XCTAssertEqual(accumulatedInternal.timeInterval(for: 1), 6)
        XCTAssertEqual(accumulatedInternal.timeInterval(for: 2), 9)
        XCTAssertEqual(accumulatedInternal.timeInterval(for: 3), 12)

        let customInternal = DelayRetryStrategy.Interval.custom { TimeInterval($0 * 2) }
        XCTAssertEqual(customInternal.timeInterval(for: 0), 0)
        XCTAssertEqual(customInternal.timeInterval(for: 1), 2)
        XCTAssertEqual(customInternal.timeInterval(for: 2), 4)
        XCTAssertEqual(customInternal.timeInterval(for: 3), 6)
    }

    func testKingfisherManagerCanRetry() {
        let exp = expectation(description: #function)

        let brokenURL = URL(string: "brokenurl")!
        stub(brokenURL, data: Data())

        let retry = StubRetryStrategy()

        _ = manager.retrieveImage(
            with: .network(brokenURL),
            options: [.retryStrategy(retry)],
            completionHandler: { result in
                XCTAssertEqual(retry.count, 3)
                exp.fulfill()
            }
        )
        waitForExpectations(timeout: 3, handler: nil)
    }

    // MARK: - DelayRetryStrategy Tests

    func testDelayRetryStrategyExceededCount() {
        let exp = expectation(description: #function)
        let blockCalled: ActorArray<Bool> = ActorArray([])

        let source = Source.network(URL(string: "url")!)
        let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))

        let group = DispatchGroup()

        group.enter()
        let context1 = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )
        retry.retry(context: context1) { decision in
            guard case RetryDecision.retry(let userInfo) = decision else {
                XCTFail("The decision should be `retry`")
                return
            }
            XCTAssertNil(userInfo)
            Task {
                await blockCalled.append(true)
                group.leave()
            }
        }

        group.enter()
        let context2 = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )
        context2.increaseRetryCount() // 1
        context2.increaseRetryCount() // 2
        context2.increaseRetryCount() // 3
        retry.retry(context: context2) { decision in
            guard case RetryDecision.stop = decision else {
                XCTFail("The decision should be `stop`")
                return
            }
            Task {
                await blockCalled.append(true)
                group.leave()
            }
        }

        group.notify(queue: .main) {
            Task {
                let result = await blockCalled.value
                XCTAssertEqual(result.count, 2)
                XCTAssertTrue(result.allSatisfy { $0 })
                exp.fulfill()
            }
        }
        waitForExpectations(timeout: 3, handler: nil)
    }

    func testDelayRetryStrategyNotRetryForErrorReason() {
        let exp = expectation(description: #function)
        // Only non-user cancel error && response error should be retied.
        let blockCalled: ActorArray<Bool> = ActorArray([])
        let source = Source.network(URL(string: "url")!)
        let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))

        let task = URLSession.shared.dataTask(with: URL(string: "url")!)

        let group = DispatchGroup()
        group.enter()
        let context1 = RetryContext(
            source: source,
            error: .requestError(reason: .taskCancelled(task: .init(task: task), token: .init()))
        )
        retry.retry(context: context1) { decision in
            guard case RetryDecision.stop = decision else {
                XCTFail("The decision should be `stop` if user cancelled the task.")
                return
            }
            Task {
                await blockCalled.append(true)
                group.leave()
            }
        }

        group.enter()
        let context2 = RetryContext(
            source: source,
            error: .cacheError(reason: .imageNotExisting(key: "any_key"))
        )
        retry.retry(context: context2) { decision in
            guard case RetryDecision.stop = decision else {
                XCTFail("The decision should be `stop` if the error type is not response error.")
                return
            }
            Task {
                await blockCalled.append(true)
                group.leave()
            }
        }

        group.notify(queue: .main) {
            Task {
                let result = await blockCalled.value
                XCTAssertEqual(result.count, 2)
                XCTAssertTrue(result.allSatisfy { $0 })
                exp.fulfill()
            }
        }
        waitForExpectations(timeout: 3, handler: nil)
    }

    func testDelayRetryStrategyDidRetried() {
        let exp = expectation(description: #function)
        let called = ActorBox(false)
        let source = Source.network(URL(string: "url")!)
        let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))
        let context = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )
        retry.retry(context: context) { decision in
            guard case RetryDecision.retry = decision else {
                XCTFail("The decision should be `retry`.")
                return
            }
            Task {
                await called.setValue(true)
                let result = await called.value
                XCTAssertTrue(result)
                exp.fulfill()
            }
        }

        waitForExpectations(timeout: 3, handler: nil)
    }

    // MARK: - NetworkRetryStrategy Tests

    func testNetworkRetryStrategyRetriesImmediatelyWhenConnected() {
        let exp = expectation(description: #function)
        let source = Source.network(URL(string: "url")!)
        let networkMonitor = TestNetworkMonitor(isConnected: true)
        let retry = NetworkRetryStrategy(
            timeoutInterval: 30,
            networkMonitor: networkMonitor
        )

        let context = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )

        retry.retry(context: context) { decision in
            guard case RetryDecision.retry(let userInfo) = decision else {
                XCTFail("The decision should be `retry` when network is connected")
                return
            }
            XCTAssertNil(userInfo)
            exp.fulfill()
        }

        waitForExpectations(timeout: 1, handler: nil)
    }

    func testNetworkRetryStrategyStopsForTaskCancelled() {
        let exp = expectation(description: #function)
        let source = Source.network(URL(string: "url")!)
        let networkMonitor = TestNetworkMonitor(isConnected: true)
        let retry = NetworkRetryStrategy(
            timeoutInterval: 30,
            networkMonitor: networkMonitor
        )

        let task = URLSession.shared.dataTask(with: URL(string: "url")!)
        let context = RetryContext(
            source: source,
            error: .requestError(reason: .taskCancelled(task: .init(task: task), token: .init()))
        )

        retry.retry(context: context) { decision in
            guard case RetryDecision.stop = decision else {
                XCTFail("The decision should be `stop` if user cancelled the task")
                return
            }
            exp.fulfill()
        }

        waitForExpectations(timeout: 1, handler: nil)
    }

    func testNetworkRetryStrategyStopsForNonResponseError() {
        let exp = expectation(description: #function)
        let source = Source.network(URL(string: "url")!)
        let networkMonitor = TestNetworkMonitor(isConnected: true)
        let retry = NetworkRetryStrategy(
            timeoutInterval: 30,
            networkMonitor: networkMonitor
        )

        let context = RetryContext(
            source: source,
            error: .cacheError(reason: .imageNotExisting(key: "any_key"))
        )

        retry.retry(context: context) { decision in
            guard case RetryDecision.stop = decision else {
                XCTFail("The decision should be `stop` if the error type is not response error")
                return
            }
            exp.fulfill()
        }

        waitForExpectations(timeout: 1, handler: nil)
    }

    func testNetworkRetryStrategyWithTimeout() {
        let exp = expectation(description: #function)
        let source = Source.network(URL(string: "url")!)
        let networkMonitor = TestNetworkMonitor(isConnected: false)
        let retry = NetworkRetryStrategy(timeoutInterval: 0.1, networkMonitor: networkMonitor)

        let context = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )

        // Test timeout behavior when network is disconnected
        retry.retry(context: context) { decision in
            guard case RetryDecision.stop = decision else {
                XCTFail("The decision should be `stop` after timeout")
                return
            }
            exp.fulfill()
        }

        waitForExpectations(timeout: 1, handler: nil)
    }

    func testNetworkRetryStrategyWaitsForReconnection() {
        let exp = expectation(description: #function)
        let source = Source.network(URL(string: "url")!)
        let networkMonitor = TestNetworkMonitor(isConnected: false)
        let retry = NetworkRetryStrategy(
            timeoutInterval: 30,
            networkMonitor: networkMonitor
        )

        let context = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )

        // Start retry when network is disconnected - should wait for reconnection
        retry.retry(context: context) { decision in
            guard case RetryDecision.retry(let userInfo) = decision else {
                XCTFail("The decision should be `retry` when network reconnects")
                return
            }
            XCTAssertNotNil(userInfo) // Should contain the observer
            exp.fulfill()
        }

        // Simulate network reconnection after a short delay
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            networkMonitor.simulateNetworkChange(isConnected: true)
        }

        waitForExpectations(timeout: 1, handler: nil)
    }

    func testNetworkRetryStrategyCancelsPreviousObserver() {
        let exp = expectation(description: #function)
        let source = Source.network(URL(string: "url")!)
        let networkMonitor = TestNetworkMonitor(isConnected: false)
        let retry = NetworkRetryStrategy(
            timeoutInterval: 30,
            networkMonitor: networkMonitor
        )

        let context = RetryContext(
            source: source,
            error: .responseError(reason: .URLSessionError(error: E()))
        )

        // First retry attempt - should create an observer
        retry.retry(context: context) { decision in
            // This should not be called since network is disconnected initially
            XCTFail("First callback should not be called immediately when network is disconnected")
        }

        // Second retry attempt - should cancel previous observer
        retry.retry(context: context) { decision in
            guard case RetryDecision.retry(let userInfo) = decision else {
                XCTFail("The second decision should be `retry`")
                return
            }
            XCTAssertNotNil(userInfo)
            exp.fulfill()
        }

        // Simulate network reconnection
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            networkMonitor.simulateNetworkChange(isConnected: true)
        }

        waitForExpectations(timeout: 1, handler: nil)
    }
}

private struct E: Error {}

final class StubRetryStrategy: RetryStrategy, @unchecked Sendable {

    let queue = DispatchQueue(label: "com.onevcat.KingfisherTests.StubRetryStrategy")
    var _count = 0
    var count: Int {
        get { queue.sync { _count } }
        set { queue.sync { _count = newValue } }
    }

    func retry(context: RetryContext, retryHandler: @escaping (RetryDecision) -> Void) {

        if count == 0 {
            XCTAssertNil(context.userInfo)
        } else {
            XCTAssertEqual(context.userInfo as! Int, count)
        }

        XCTAssertEqual(context.retriedCount, count)

        count += 1
        if count == 3 {
            retryHandler(.stop)
        } else {
            retryHandler(.retry(userInfo: count))
        }
    }
}

// MARK: - Test Network Monitoring Implementations

/// A test implementation of NetworkMonitoring that allows controlling network state for testing.
final class TestNetworkMonitor: @unchecked Sendable, NetworkMonitoring {
    private let queue = DispatchQueue(label: "com.onevcat.KingfisherTests.TestNetworkMonitor", attributes: .concurrent)
    private var _isConnected: Bool
    private var observers: [TestNetworkObserver] = []

    var isConnected: Bool {
        get { queue.sync { _isConnected } }
        set { queue.sync(flags: .barrier) { _isConnected = newValue } }
    }

    init(isConnected: Bool = true) {
        self._isConnected = isConnected
    }

    func observeConnectivity(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void) -> NetworkObserver {
        let observer = TestNetworkObserver(
            timeoutInterval: timeoutInterval,
            callback: callback,
            monitor: self
        )

        queue.sync(flags: .barrier) {
            observers.append(observer)
        }

        return observer
    }

    /// Simulates network state change and notifies all observers.
    func simulateNetworkChange(isConnected: Bool) {
        queue.sync(flags: .barrier) {
            _isConnected = isConnected
            let activeObservers = observers
            observers.removeAll()

            DispatchQueue.main.async {
                activeObservers.forEach { $0.notify(isConnected: isConnected) }
            }
        }
    }

    /// Removes an observer from the list.
    func removeObserver(_ observer: TestNetworkObserver) {
        queue.sync(flags: .barrier) {
            observers.removeAll { $0 === observer }
        }
    }
}

/// Test implementation of NetworkObserver for testing purposes.
final class TestNetworkObserver: @unchecked Sendable, NetworkObserver {
    let timeoutInterval: TimeInterval?
    let callback: @Sendable (Bool) -> Void
    private weak var monitor: TestNetworkMonitor?
    private var timeoutWorkItem: DispatchWorkItem?
    private let queue = DispatchQueue(label: "com.onevcat.KingfisherTests.TestNetworkObserver", qos: .utility)

    init(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void, monitor: TestNetworkMonitor) {
        self.timeoutInterval = timeoutInterval
        self.callback = callback
        self.monitor = monitor

        // Set up timeout if specified
        if let timeoutInterval = timeoutInterval {
            let workItem = DispatchWorkItem { [weak self] in
                self?.notify(isConnected: false)
            }
            timeoutWorkItem = workItem
            queue.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
        }
    }

    func notify(isConnected: Bool) {
        queue.async { [weak self] in
            guard let self else { return }

            // Cancel timeout if we're notifying
            timeoutWorkItem?.cancel()
            timeoutWorkItem = nil

            // Remove from monitor
            monitor?.removeObserver(self)

            // Call the callback
            DispatchQueue.main.async {
                self.callback(isConnected)
            }
        }
    }

    func cancel() {
        queue.async { [weak self] in
            guard let self else { return }

            // Cancel timeout
            timeoutWorkItem?.cancel()
            timeoutWorkItem = nil

            // Remove from monitor
            monitor?.removeObserver(self)
        }
    }
}
